import throttle from 'lodash.throttle'
import { ExcalidrawImperativeAPI } from '../../types'
import { APP_NAME, ENV, EVENT } from '../../constants'
import { ImportedDataState } from '../../data/types'
import { ExcalidrawElement, InitializedExcalidrawImageElement } from '../../element/types'
import { getSceneVersion, restoreElements } from '../ExcalidrawBase'
import { Collaborator, Gesture } from '../../types'
import { preventUnload, resolvablePromise, withBatchedUpdates } from '../../utils'
import {
  CURSOR_SYNC_TIMEOUT,
  FILE_UPLOAD_MAX_BYTES,
  FIREBASE_STORAGE_PREFIXES,
  INITIAL_SCENE_UPDATE_TIMEOUT,
  LOAD_IMAGES_TIMEOUT,
  WS_SCENE_EVENT_TYPES,
  SYNC_FULL_SCENE_INTERVAL_MS
} from '../app_constants'
import {
  generateCollaborationLinkData,
  getSyncableElements,
  SocketUpdateDataSource,
  SyncableExcalidrawElement
} from '../data'
import { loadFilesFromFirebase, saveFilesToFirebase, saveToFirebase } from '../data/firebase'
import Portal from './Portal'
import { t } from '/imports/i18n'
import { UserIdleState } from '../../types'
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from '../../constants'
import { encodeFilesForUpload, FileManager, updateStaleImageStatuses } from '../data/FileManager'
import { AbortError } from '../../errors'
import { isImageElement, isInitializedImageElement } from '../../element/typeChecks'
import { newElementWith } from '../../element/mutateElement'
import { ReconciledElements, reconcileElements as _reconcileElements } from './reconciliation'
import { decryptData } from '../../data/encryption'
import { resetBrowserStateVersions } from '../data/tabSync'
import { LocalData } from '../data/LocalData'
import { atom } from 'jotai'
import { jotaiStore } from '../../../store/jotai'
import { attachDebugLabel, globalState } from '/imports/store'

export const collabAPIAtom = attachDebugLabel(atom<CollabAPI | null>(null), 'collabAPIAtom')
export const collabDialogShownAtom = attachDebugLabel(atom(false), 'collabDialogShownAtom')
export const isCollaboratingAtom = attachDebugLabel(atom(false), 'isCollaboratingAtom')

type CollabInstance = InstanceType<typeof CollabClass>

export interface CollabAPI {
  /** function so that we can access the latest value from stale callbacks */
  isCollaborating: () => boolean
  onPointerUpdate: CollabInstance['onPointerUpdate']
  startCollaboration: CollabInstance['startCollaboration']
  stopCollaboration: CollabInstance['stopCollaboration']
  syncElements: CollabInstance['syncElements']
  fetchImageFilesFromFirebase: CollabInstance['fetchImageFilesFromFirebase']
}

export class CollabClass {
  portal: Portal
  fileManager: FileManager
  activeIntervalId: number | null
  idleTimeoutId: number | null

  private socketInitializationTimer?: number
  private lastBroadcastedOrReceivedSceneVersion = -1
  private collaborators = new Map<string, Collaborator>()

  constructor(public excalidrawAPI: ExcalidrawImperativeAPI) {
    this.portal = new Portal(this)
    this.fileManager = new FileManager({
      getFiles: async fileIds => {
        const { roomId, roomKey } = this.portal
        if (!roomId || !roomKey) {
          throw new AbortError()
        }

        return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds)
      },
      saveFiles: async ({ addedFiles }) => {
        const { roomId, roomKey } = this.portal
        if (!roomId || !roomKey) {
          throw new AbortError()
        }

        return saveFilesToFirebase({
          prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
          files: await encodeFilesForUpload({
            files: addedFiles,
            encryptionKey: roomKey,
            maxBytes: FILE_UPLOAD_MAX_BYTES
          })
        })
      }
    })
    this.activeIntervalId = null
    this.idleTimeoutId = null
  }

  mount() {
    window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload)
    window.addEventListener(EVENT.UNLOAD, this.onUnload)

    const collabAPI: CollabAPI = {
      isCollaborating: this.isCollaborating,
      onPointerUpdate: this.onPointerUpdate,
      startCollaboration: this.startCollaboration,
      syncElements: this.syncElements,
      fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
      stopCollaboration: this.stopCollaboration
    }

    jotaiStore.set(collabAPIAtom, collabAPI)
    this.startCollaboration(null)

    if (process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.DEVELOPMENT) {
      window.collab = window.collab || ({} as Window['collab'])
      Object.defineProperties(window, {
        collab: {
          configurable: true,
          value: this
        }
      })
    }
  }

  unmount() {
    window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload)
    window.removeEventListener(EVENT.UNLOAD, this.onUnload)
    window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove)
    window.removeEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange)
    if (this.activeIntervalId) {
      window.clearInterval(this.activeIntervalId)
      this.activeIntervalId = null
    }
    if (this.idleTimeoutId) {
      window.clearTimeout(this.idleTimeoutId)
      this.idleTimeoutId = null
    }
  }

  isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!

  private setIsCollaborating = (isCollaborating: boolean) => {
    jotaiStore.set(isCollaboratingAtom, isCollaborating)
  }

  private onUnload = () => {
    this.destroySocketClient({ isUnload: true })
  }

  private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
    console.log('isSavingChangesToRemote', globalState.isSavingChangesToRemote)
    if (globalState.isSavingChangesToRemote) {
      preventUnload(event)
    }
  })

  saveCollabRoomToFirebase = async (syncableElements: readonly SyncableExcalidrawElement[]) => {
    try {
      const savedData = await saveToFirebase(
        this.portal,
        syncableElements,
        this.excalidrawAPI.getAppState()
      )

      if (this.isCollaborating() && savedData && savedData.reconciledElements) {
        this.handleRemoteSceneUpdate(this.reconcileElements(savedData.reconciledElements))
      }
    } catch (error: any) {
      console.error(error)
    }
  }

  stopCollaboration = (keepRemoteState = true) => {
    this.queueBroadcastAllElements.cancel()
    this.queueSaveToFirebase.cancel()
    this.loadImageFiles.cancel()

    this.saveCollabRoomToFirebase(
      getSyncableElements(this.excalidrawAPI.getSceneElementsIncludingDeleted())
    )

    if (this.portal.socket && this.fallbackInitializationHandler) {
      this.portal.socket.off('connect_error', this.fallbackInitializationHandler)
    }

    if (!keepRemoteState) {
      LocalData.fileStorage.reset()
      this.destroySocketClient()
    } else if (window.confirm(t('alerts.collabStopOverridePrompt'))) {
      // hack to ensure that we prefer we disregard any new browser state
      // that could have been saved in other tabs while we were collaborating
      resetBrowserStateVersions()

      window.history.pushState({}, APP_NAME, window.location.origin)
      this.destroySocketClient()

      LocalData.fileStorage.reset()

      const elements = this.excalidrawAPI.getSceneElementsIncludingDeleted().map(element => {
        if (isImageElement(element) && element.status === 'saved') {
          return newElementWith(element, { status: 'pending' })
        }
        return element
      })

      this.excalidrawAPI.updateScene({
        elements,
        commitToHistory: false
      })
    }
  }

  private destroySocketClient = (opts?: { isUnload: boolean }) => {
    this.lastBroadcastedOrReceivedSceneVersion = -1
    this.portal.close()
    this.fileManager.reset()
    if (!opts?.isUnload) {
      this.setIsCollaborating(false)
      this.collaborators = new Map()
      this.excalidrawAPI.updateScene({
        collaborators: this.collaborators
      })
      LocalData.resumeSave('collaboration')
    }
  }

  private fetchImageFilesFromFirebase = async (opts: {
    elements: readonly ExcalidrawElement[]
    /**
     * Indicates whether to fetch files that are errored or pending and older
     * than 10 seconds.
     *
     * Use this as a machanism to fetch files which may be ok but for some
     * reason their status was not updated correctly.
     */
    forceFetchFiles?: boolean
  }) => {
    const unfetchedImages = opts.elements
      .filter(element => {
        return (
          isInitializedImageElement(element) &&
          !this.fileManager.isFileHandled(element.fileId) &&
          !element.isDeleted &&
          (opts.forceFetchFiles
            ? element.status !== 'pending' || Date.now() - element.updated > 10000
            : element.status === 'saved')
        )
      })
      .map(element => (element as InitializedExcalidrawImageElement).fileId)

    return await this.fileManager.getFiles(unfetchedImages)
  }

  private decryptPayload = async (
    iv: Uint8Array,
    encryptedData: ArrayBuffer,
    decryptionKey: string
  ) => {
    try {
      const decrypted = await decryptData(iv, encryptedData, decryptionKey)

      const decodedData = new TextDecoder('utf-8').decode(new Uint8Array(decrypted))
      return JSON.parse(decodedData)
    } catch (error) {
      window.alert(t('alerts.decryptFailed'))
      console.error(error)
      return {
        type: 'INVALID_RESPONSE'
      }
    }
  }

  private fallbackInitializationHandler: null | (() => any) = null

  public startCollaboration = async (
    existingRoomLinkData: null | { roomId: string; roomKey: string }
  ): Promise<ImportedDataState | null> => {
    if (this.portal.socket) {
      return null
    }

    let roomId
    let roomKey

    if (existingRoomLinkData) {
      // eslint-disable-next-line @typescript-eslint/no-extra-semi
      ;({ roomId, roomKey } = existingRoomLinkData)
    } else {
      // eslint-disable-next-line @typescript-eslint/no-extra-semi
      ;({ roomId, roomKey } = await generateCollaborationLinkData())
      // TODO use boardId board/:boardId
      // window.history.pushState({}, APP_NAME, getCollaborationLink({ roomId, roomKey }))
    }

    const scenePromise = resolvablePromise<ImportedDataState | null>()

    this.setIsCollaborating(true)
    LocalData.pauseSave('collaboration')

    const { default: socketIOClient } = await import('socket.io-client')
    const fallbackInitializationHandler = () => {
      this.initializeRoom({
        roomLinkData: existingRoomLinkData,
        fetchScene: true
      }).then(scene => {
        scenePromise.resolve(scene)
      })
    }
    this.fallbackInitializationHandler = fallbackInitializationHandler

    try {
      this.portal.socket = this.portal.open(
        socketIOClient('/', { transports: ['websocket', 'polling'] }),
        roomId,
        roomKey
      )

      this.portal.socket.once('connect_error', fallbackInitializationHandler)
    } catch (error: any) {
      console.error(error)
      return null
    }

    if (!existingRoomLinkData) {
      const elements = this.excalidrawAPI.getSceneElements().map(element => {
        if (isImageElement(element) && element.status === 'saved') {
          return newElementWith(element, { status: 'pending' })
        }
        return element
      })
      // remove deleted elements from elements array & history to ensure we don't
      // expose potentially sensitive user data in case user manually deletes
      // existing elements (or clears scene), which would otherwise be persisted
      // to database even if deleted before creating the room.
      this.excalidrawAPI.history.clear()
      this.excalidrawAPI.updateScene({
        elements,
        commitToHistory: true
      })

      // this.saveCollabRoomToFirebase(getSyncableElements(elements))
    }

    // fallback in case you're not alone in the room but still don't receive
    // initial SCENE_INIT message
    this.socketInitializationTimer = window.setTimeout(
      fallbackInitializationHandler,
      INITIAL_SCENE_UPDATE_TIMEOUT
    )

    // All socket listeners are moving to Portal
    this.portal.socket.on(
      'client-broadcast',
      async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
        if (!this.portal.roomKey) {
          return
        }

        const decryptedData = await this.decryptPayload(iv, encryptedData, this.portal.roomKey)

        switch (decryptedData.type) {
          case 'INVALID_RESPONSE':
            return
          case WS_SCENE_EVENT_TYPES.INIT: {
            if (!this.portal.socketInitialized) {
              this.initializeRoom({ fetchScene: false })
              const remoteElements = decryptedData.payload.elements
              const reconciledElements = this.reconcileElements(remoteElements)
              this.handleRemoteSceneUpdate(reconciledElements, {
                init: true
              })
              // noop if already resolved via init from firebase
              scenePromise.resolve({
                elements: reconciledElements,
                scrollToContent: true
              })
            }
            break
          }
          case WS_SCENE_EVENT_TYPES.UPDATE:
            this.handleRemoteSceneUpdate(this.reconcileElements(decryptedData.payload.elements))
            break
          case 'MOUSE_LOCATION': {
            const { pointer, button, username, selectedElementIds } = decryptedData.payload
            const socketId: SocketUpdateDataSource['MOUSE_LOCATION']['payload']['socketId'] =
              decryptedData.payload.socketId ||
              // @ts-ignore legacy, see #2094 (#2097)
              decryptedData.payload.socketID

            const collaborators = new Map(this.collaborators)
            const user = collaborators.get(socketId) || {}!
            user.pointer = pointer
            user.button = button
            user.selectedElementIds = selectedElementIds
            user.username = username
            collaborators.set(socketId, user)
            this.excalidrawAPI.updateScene({
              collaborators
            })
            break
          }
          case 'IDLE_STATUS': {
            const { userState, socketId, username } = decryptedData.payload
            const collaborators = new Map(this.collaborators)
            const user = collaborators.get(socketId) || {}!
            user.userState = userState
            user.username = username
            this.excalidrawAPI.updateScene({
              collaborators
            })
            break
          }
        }
      }
    )

    this.portal.socket.on('first-in-room', async () => {
      if (this.portal.socket) {
        this.portal.socket.off('first-in-room')
      }
      const sceneData = await this.initializeRoom({
        fetchScene: true,
        roomLinkData: existingRoomLinkData
      })
      scenePromise.resolve(sceneData)
    })

    this.initializeIdleDetector()

    return scenePromise
  }

  private initializeRoom = async ({
    fetchScene,
    roomLinkData
  }:
    | {
        fetchScene: true
        roomLinkData: { roomId: string; roomKey: string } | null
      }
    | { fetchScene: false; roomLinkData?: null }) => {
    clearTimeout(this.socketInitializationTimer!)
    if (this.portal.socket && this.fallbackInitializationHandler) {
      this.portal.socket.off('connect_error', this.fallbackInitializationHandler)
    }
    if (fetchScene && roomLinkData && this.portal.socket) {
      this.excalidrawAPI.resetScene()

      try {
        // const elements = await loadFromFirebase(
        //   roomLinkData.roomId,
        //   roomLinkData.roomKey,
        //   this.portal.socket
        // )
        // if (elements) {
        //   this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(elements))
        //   return {
        //     elements,
        //     scrollToContent: true
        //   }
        // }
      } catch (error: any) {
        // log the error and move on. other peers will sync us the scene.
        console.error(error)
      } finally {
        this.portal.socketInitialized = true
      }
    } else {
      this.portal.socketInitialized = true
    }
    return null
  }

  private reconcileElements = (
    remoteElements: readonly ExcalidrawElement[]
  ): ReconciledElements => {
    const localElements = this.getSceneElementsIncludingDeleted()
    const appState = this.excalidrawAPI.getAppState()

    remoteElements = restoreElements(remoteElements, null, false)

    const reconciledElements = _reconcileElements(localElements, remoteElements, appState)

    // Avoid broadcasting to the rest of the collaborators the scene
    // we just received!
    // Note: this needs to be set before updating the scene as it
    // synchronously calls render.
    this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(reconciledElements))

    return reconciledElements
  }

  private loadImageFiles = throttle(async () => {
    const { loadedFiles, erroredFiles } = await this.fetchImageFilesFromFirebase({
      elements: this.excalidrawAPI.getSceneElementsIncludingDeleted()
    })

    this.excalidrawAPI.addFiles(loadedFiles)

    updateStaleImageStatuses({
      excalidrawAPI: this.excalidrawAPI,
      erroredFiles,
      elements: this.excalidrawAPI.getSceneElementsIncludingDeleted()
    })
  }, LOAD_IMAGES_TIMEOUT)

  private handleRemoteSceneUpdate = (
    elements: ReconciledElements,
    { init = false }: { init?: boolean } = {}
  ) => {
    this.excalidrawAPI.updateScene({
      elements,
      commitToHistory: !!init
    })

    // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
    // when we receive any messages from another peer. This UX can be pretty rough -- if you
    // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
    // right now we think this is the right tradeoff.
    this.excalidrawAPI.history.clear()

    this.loadImageFiles()
  }

  private onPointerMove = () => {
    if (this.idleTimeoutId) {
      window.clearTimeout(this.idleTimeoutId)
      this.idleTimeoutId = null
    }

    this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD)

    if (!this.activeIntervalId) {
      this.activeIntervalId = window.setInterval(this.reportActive, ACTIVE_THRESHOLD)
    }
  }

  private onVisibilityChange = () => {
    if (document.hidden) {
      if (this.idleTimeoutId) {
        window.clearTimeout(this.idleTimeoutId)
        this.idleTimeoutId = null
      }
      if (this.activeIntervalId) {
        window.clearInterval(this.activeIntervalId)
        this.activeIntervalId = null
      }
      this.onIdleStateChange(UserIdleState.AWAY)
    } else {
      this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD)
      this.activeIntervalId = window.setInterval(this.reportActive, ACTIVE_THRESHOLD)
      this.onIdleStateChange(UserIdleState.ACTIVE)
    }
  }

  private reportIdle = () => {
    this.onIdleStateChange(UserIdleState.IDLE)
    if (this.activeIntervalId) {
      window.clearInterval(this.activeIntervalId)
      this.activeIntervalId = null
    }
  }

  private reportActive = () => {
    this.onIdleStateChange(UserIdleState.ACTIVE)
  }

  private initializeIdleDetector = () => {
    document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove)
    document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange)
  }

  setCollaborators(sockets: string[]) {
    const collaborators: InstanceType<typeof CollabClass>['collaborators'] = new Map()
    for (const socketId of sockets) {
      if (this.collaborators.has(socketId)) {
        collaborators.set(socketId, this.collaborators.get(socketId)!)
      } else {
        collaborators.set(socketId, {})
      }
    }
    this.collaborators = collaborators
    this.excalidrawAPI.updateScene({ collaborators })
  }

  public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
    this.lastBroadcastedOrReceivedSceneVersion = version
  }

  public getLastBroadcastedOrReceivedSceneVersion = () => {
    return this.lastBroadcastedOrReceivedSceneVersion
  }

  public getSceneElementsIncludingDeleted = () => {
    return this.excalidrawAPI.getSceneElementsIncludingDeleted()
  }

  onPointerUpdate = throttle(
    (payload: {
      pointer: SocketUpdateDataSource['MOUSE_LOCATION']['payload']['pointer']
      button: SocketUpdateDataSource['MOUSE_LOCATION']['payload']['button']
      pointersMap: Gesture['pointers']
    }) => {
      payload.pointersMap.size < 2 &&
        this.portal.socket &&
        this.portal.broadcastMouseLocation(payload)
    },
    CURSOR_SYNC_TIMEOUT
  )

  onIdleStateChange = (userState: UserIdleState) => {
    this.portal.broadcastIdleChange(userState)
  }

  broadcastElements = (elements: readonly ExcalidrawElement[]) => {
    if (getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion()) {
      this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false)
      this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements)
      this.queueBroadcastAllElements()
    }
  }

  syncElements = (elements: readonly ExcalidrawElement[]) => {
    this.broadcastElements(elements)
    this.queueSaveToFirebase()
  }

  queueBroadcastAllElements = throttle(() => {
    this.portal.broadcastScene(
      WS_SCENE_EVENT_TYPES.UPDATE,
      this.excalidrawAPI.getSceneElementsIncludingDeleted(),
      true
    )
    const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion()
    const newVersion = Math.max(
      currentVersion,
      getSceneVersion(this.getSceneElementsIncludingDeleted())
    )
    this.setLastBroadcastedOrReceivedSceneVersion(newVersion)
  }, SYNC_FULL_SCENE_INTERVAL_MS)

  queueSaveToFirebase = throttle(
    () => {
      if (this.portal.socketInitialized) {
        // this.saveCollabRoomToFirebase(
        //   getSyncableElements(this.excalidrawAPI.getSceneElementsIncludingDeleted())
        // )
      }
    },
    SYNC_FULL_SCENE_INTERVAL_MS,
    { leading: false }
  )

  handleClose = () => {
    jotaiStore.set(collabDialogShownAtom, false)
  }
}

declare global {
  interface Window {
    collab: InstanceType<typeof CollabClass>
  }
}

if (process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.DEVELOPMENT) {
  window.collab = window.collab || ({} as Window['collab'])
}
